Skip to content

Comments

fix: correctly set color and highlight of pasted text#2033

Open
gm1357 wants to merge 7 commits intosuperdoc-dev:mainfrom
gm1357:fix/apply-color-on-paste
Open

fix: correctly set color and highlight of pasted text#2033
gm1357 wants to merge 7 commits intosuperdoc-dev:mainfrom
gm1357:fix/apply-color-on-paste

Conversation

@gm1357
Copy link

@gm1357 gm1357 commented Feb 15, 2026

Problem

When pasting text with color or highlight from Word, Google Docs, or within SuperDoc, the toolbar shows the correct color but the text renders without it.

Cause

Pasted colors were coming as rgb() format, but the rendering pipeline's normalizeColor only handles hex, it prepends # to non-hex values, producing invalid strings like #rgb(255, 0, 0).

Fix

  • Added cssColorToHex utility that converts rgb()/rgba() values to hex format
  • Applied it in the Color extension's parseDOM so text colors are stored as hex at the point of entry
  • Applied it in the Highlight extension's parseDOM
  • Added a { style: 'background-color' } parse rule so highlights pasted as <span style="background-color: ..."> (Word/Google Docs format) are correctly captured, previously only <mark> tags were matched

Comparisons

Before

Screen.Recording.2026-02-15.152442.mp4
Screen.Recording.2026-02-15.152703.mp4

After

Screen.Recording.2026-02-15.153003.mp4
Screen.Recording.2026-02-15.152828.mp4

Copilot AI review requested due to automatic review settings February 15, 2026 18:47
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 459e0b0ab2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines 58 to 59
if (!value || value === 'transparent' || value === 'inherit') return false;
return { color: cssColorToHex(value) };

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip transparent rgba background colors in highlight parse

The new background-color parse rule treats any non-transparent/inherit value as a highlight, but many pasted HTML sources encode “no background” as rgba(0, 0, 0, 0). In that case cssColorToHex drops alpha and returns #000000, so plain text is imported with an unintended black highlight mark. This regression is introduced by the new style-based parse path and will surface on paste inputs that use transparent RGBA values.

Useful? React with 👍 / 👎.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes pasted text color/highlight rendering by normalizing pasted CSS rgb()/rgba() color values into hex at parse-time, so downstream rendering logic receives consistent color formats.

Changes:

  • Added cssColorToHex utility and exported it from core utilities.
  • Applied cssColorToHex in Color and Highlight extensions’ DOM parsing (including a new background-color style parse rule for highlights).
  • Added unit tests for cssColorToHex.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
packages/super-editor/src/extensions/highlight/highlight.js Normalize pasted highlight colors and capture <span style="background-color: ..."> highlights.
packages/super-editor/src/extensions/color/color.js Normalize pasted text color values to hex during DOM parsing.
packages/super-editor/src/core/utilities/cssColorToHex.js New utility to convert CSS rgb/rgba strings to hex (plus passthrough behavior).
packages/super-editor/src/core/utilities/index.js Re-export cssColorToHex from the utilities barrel.
packages/super-editor/src/core/utilities/tests/utilities.test.js Added test coverage for cssColorToHex.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 18 to 23
// Parse rgb(r, g, b) or rgba(r, g, b, a)
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch;
return '#' + [r, g, b].map((c) => Number(c).toString(16).padStart(2, '0')).join('');
}
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rgb/rgba parsing is case-sensitive and not anchored to the closing ")", so values like "RGB(255,0,0)" or strings with extra trailing characters won't be converted. Also, channels aren't clamped to 0–255; if a value like "rgb(300,0,0)" is encountered, the generated hex will be longer than 6 digits and invalid. Consider using a case-insensitive, fully-anchored regex and clamping channels to [0,255] before hex conversion.

Copilot uses AI. Check for mistakes.
color: {
default: null,
parseDOM: (element) => element.getAttribute('data-color') || element.style.backgroundColor,
parseDOM: (element) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor),
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseDOM prefers data-color as-is, so any existing content (or copy/paste within SuperDoc) that has data-color="rgb(...)"/"rgba(...)" will still bypass cssColorToHex and reintroduce the original rendering issue. Consider applying cssColorToHex to the data-color value too (and falling back to backgroundColor if it returns null).

Suggested change
parseDOM: (element) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor),
parseDOM: (element) => {
const dataColor = element.getAttribute('data-color');
const parsedDataColor = cssColorToHex(dataColor);
if (parsedDataColor) {
return parsedDataColor;
}
return cssColorToHex(element.style.backgroundColor);
},

Copilot uses AI. Check for mistakes.
{
style: 'background-color',
getAttrs: (value) => {
if (!value || value === 'transparent' || value === 'inherit') return false;
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The transparent / inherit checks are strict string comparisons on the raw style value. Since style values can vary in casing or include whitespace (and you already trim in cssColorToHex), it’d be safer to normalize value (trim + lowercase) before these checks to avoid accidentally creating a highlight mark for " transparent ", "Transparent", etc.

Suggested change
if (!value || value === 'transparent' || value === 'inherit') return false;
if (!value) return false;
const normalized = value.trim().toLowerCase();
if (normalized === 'transparent' || normalized === 'inherit') return false;

Copilot uses AI. Check for mistakes.
style: 'background-color',
getAttrs: (value) => {
if (!value || value === 'transparent' || value === 'inherit') return false;
return { color: cssColorToHex(value) };
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getAttrs always returns a highlight mark when value is truthy, even if cssColorToHex(value) returns null (e.g. whitespace-only). That would create a highlight mark with color: null, which may lead to unexpected default <mark> styling. Consider computing the converted color first and returning false when the conversion yields null.

Suggested change
return { color: cssColorToHex(value) };
const convertedColor = cssColorToHex(value);
if (!convertedColor) return false;
return { color: convertedColor };

Copilot uses AI. Check for mistakes.
Comment on lines 2 to 6
* Converts a CSS color value to hex format (#RRGGBB).
* Handles rgb(), rgba(), hex, and returns null for empty/invalid input.
*
* @param {string|null|undefined} cssColor - A CSS color string
* @returns {string|null} Hex color string or null
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc says this converts to "#RRGGBB" and returns null for empty/invalid input, but the implementation (and tests) currently pass through 3-digit hex and return the original string for any unrecognized value (including invalid colors). Please either tighten validation + normalize to 6-digit hex, or update the docstring to match the actual behavior/contract.

Suggested change
* Converts a CSS color value to hex format (#RRGGBB).
* Handles rgb(), rgba(), hex, and returns null for empty/invalid input.
*
* @param {string|null|undefined} cssColor - A CSS color string
* @returns {string|null} Hex color string or null
* Converts a CSS color value to a hex color when possible.
*
* - If `cssColor` is null/undefined/empty (or whitespace only), returns null.
* - If `cssColor` is a 3- or 6-digit hex color (e.g. `#fff`, `#ffffff`), it is returned as-is.
* - If `cssColor` is in rgb() / rgba() form, it is converted to a 6-digit hex string (#RRGGBB).
* - For any other value (named colors, other formats, or unrecognized strings), the trimmed input is returned unchanged.
*
* @param {string|null|undefined} cssColor - A CSS color string
* @returns {string|null} A 6-digit hex color string (#RRGGBB) when converted, the original color string for non-rgb(a)/unrecognized input, or null for empty input

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@tupizz tupizz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing! Lgtm

Copy link
Contributor

@caio-pizzol caio-pizzol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @gm1357, great start!

The root cause analysis is spot on and converting at parse time is the right approach. cssColorToHex is clean and well-tested.

Couple of things to address before merging (details in inline comments)

Also, it would be great to add a behavior test in tests/visual/tests/behavior/formatting/ that inserts HTML with rgb() colors and screenshots the result

style: 'background-color',
getAttrs: (value) => {
if (!value || value === 'transparent' || value === 'inherit') return false;
return { color: cssColorToHex(value) };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cssColorToHex(value) can return null here -- for example when the pasted HTML has rgba(0, 0, 0, 0) (a common way browsers encode "no background"). That value passes the transparent/inherit guard on line 58, but cssColorToHex correctly returns null for zero-alpha.

The problem is this still returns { color: null }, which tells ProseMirror to create a highlight mark with no color -- a phantom mark in the document.

  getAttrs: (value) => {
    if (!value || value === 'transparent' || value === 'inherit') return false;
    const color = cssColorToHex(value);
    return color ? { color } : false;
  },

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adressed on 36d4a5b

color: {
default: null,
parseDOM: (element) => element.getAttribute('data-color') || element.style.backgroundColor,
parseDOM: (element) => element.getAttribute('data-color') || cssColorToHex(element.style.backgroundColor),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data-color is used as-is without going through cssColorToHex. This matters because SuperDoc sets data-color to whatever the stored color attribute is (line 44). If a document was edited before this fix, the stored value could be rgb(255, 0, 0). When that content is copy-pasted within SuperDoc, the pasted <mark> has data-color="rgb(255, 0, 0)" which gets stored raw.

Downstream, normalizeColor in pm-adapter/src/utilities.ts prepends # to anything that doesn't start with it, producing "#rgb(255, 0, 0)" which breaks rendering in presentation mode.

  parseDOM: (element) => cssColorToHex(element.getAttribute('data-color')) || cssColorToHex(element.style.backgroundColor),

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adressed on 36d4a5b

{ tag: 'mark' },
{
style: 'background-color',
getAttrs: (value) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the transparent/inherit check here and the null handling inside cssColorToHex are doing related work in two places. If you add the null guard from comment 1, you could simplify this to just:

  getAttrs: (value) => {
    if (!value) return false;
    const color = cssColorToHex(value);
    return color ? { color } : false;
  },

Since cssColorToHex('transparent') would return "transparent" (named color passthrough), you'd want to either keep the explicit check here, or teach cssColorToHex to return null for CSS keywords like transparent/inherit/initial.

Either way works -- just pick one place for that logic.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adressed on 7a02659.

Chose to keep the logic explictly here so that cssColorToHex can be a bit more agnostic on how to handle those values.

color: {
default: null,
parseDOM: (el) => el.style.color?.replace(/['"]+/g, ''),
parseDOM: (el) => cssColorToHex(el.style.color?.replace(/['"]+/g, '')),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .replace(/['"]+/g, '') is left over from the previous code -- el.style.color is a browser-computed property that never contains quotes.

Safe to drop it now that you're already editing this line:

parseDOM: (el) => cssColorToHex(el.style.color),

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adressed on 36d4a5b

* Named colors are returned as-is.
*
* @param {string|null|undefined} cssColor - A CSS color string
* @returns {string|null} Hex color string or null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@returns {string|null} Hex color string or null -- this can also return named colors ("red") and 3-digit hex ("#f00") unchanged, so "Hex color string" isn't quite right.

Something like Normalized color string or null would match the actual contract better.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on 8611418

@gm1357
Copy link
Author

gm1357 commented Feb 18, 2026

Thanks for the reviews @tupizz and @caio-pizzol!

@caio-pizzol I adressed your in-line comments. Regarding the behaviour tests you suggested, I will take a look at it later and add it.

@gm1357 gm1357 requested a review from caio-pizzol February 20, 2026 19:24
@gm1357
Copy link
Author

gm1357 commented Feb 20, 2026

@caio-pizzol I added some behaviour tests for this change, can you do another review when possible?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants